package jmine.tec.web.servlet.cipher;

import java.io.ByteArrayInputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.OutputStream;
import java.security.InvalidKeyException;
import java.security.Key;
import java.security.KeyPair;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.util.HashMap;

import javax.crypto.Cipher;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.DESKeySpec;

import jmine.tec.utils.Tuple;
import jmine.tec.utils.UnmodifiableTuple;
import jmine.tec.utils.collection.MultiMap;
import jmine.tec.utils.collection.impl.ListMultiMap;
import jmine.tec.utils.cripto.cipher.CipherInputStream;
import jmine.tec.utils.cripto.cipher.CipherOutputStream;
import jmine.tec.utils.cripto.key.GeradorChaveRSA;
import jmine.tec.utils.cripto.key.PublicKeyLocator;
import jmine.tec.utils.cripto.key.impl.DynamicKeyPairHolder;
import jmine.tec.utils.io.EncodingUtils;
import jmine.tec.utils.io.IOUtils;
import jmine.tec.utils.io.ReversibleOutputStream;
import jmine.tec.utils.io.http.Connector;
import jmine.tec.utils.io.http.HttpMethod;
import jmine.tec.utils.io.http.exception.HttpClientErrorException;
import jmine.tec.utils.io.http.exception.HttpException;
import jmine.tec.utils.io.http.exception.RetryRequestException;
import jmine.tec.utils.io.http.impl.AbstractConnector;
import bancosys.tec.utils.md5sum.MD5SumTool;

/**
 * Implementa o protocolo de conversacao segura. Diferente de {@link SecureCommunicationConnector}, decora um {@link Connector} qualquer
 * 
 * @author takeshi
 */
public class SecureCommunicationConnectorWrapper extends AbstractConnector implements SecureCommunicationProtocol {

    private final String keyPath;

    private final Connector delegate;

    private volatile Cipher cipher;

    private final int chunkSize;

    private final String serverCertificateName;

    private final PublicKeyLocator keyLocator;

    private final Key originalDecodeKey;

    private volatile Key decodeKey;

    private volatile Key encodeKey;

    private volatile int status;

    private final boolean useSymetricKey;

    private volatile boolean serverSupportsSymetricKeys;

    private String cipherName;

    private final Cipher handshakeCipher;

    /**
     * C'tor
     * 
     * @param decodeKey a chave usada para decodificar
     * @param keyPath o caminho que deve ser enviado ao servidor
     * @param delegate o {@link Connector}
     * @param cipher o {@link Cipher}
     * @param chunkSize tamanho da janela para criptografia
     * @param keyLocator the PublicKeyLocator
     * @param serverCertificateName the server certificate name
     */
    public SecureCommunicationConnectorWrapper(Key decodeKey, String keyPath, Connector delegate, Cipher cipher, int chunkSize,
            PublicKeyLocator keyLocator, String serverCertificateName) {
        this(decodeKey, keyPath, delegate, cipher, chunkSize, keyLocator, serverCertificateName, true);
    }

    /**
     * C'tor
     * 
     * @param decodeKey a chave usada para decodificar
     * @param keyPath o caminho que deve ser enviado ao servidor
     * @param delegate o {@link Connector}
     * @param cipher o {@link Cipher}
     * @param chunkSize tamanho da janela para criptografia
     * @param keyLocator the PublicKeyLocator
     * @param serverCertificateName the server certificate name
     * @param symetric if it should use symetric keys
     */
    public SecureCommunicationConnectorWrapper(Key decodeKey, String keyPath, Connector delegate, Cipher cipher, int chunkSize,
            PublicKeyLocator keyLocator, String serverCertificateName, boolean symetric) {
        this.decodeKey = decodeKey;
        this.useSymetricKey = symetric;
        this.originalDecodeKey = decodeKey;
        this.serverCertificateName = serverCertificateName;
        this.keyPath = keyPath;
        this.delegate = delegate;
        this.handshakeCipher = cipher;
        this.cipher = cipher;
        this.chunkSize = chunkSize;
        this.keyLocator = keyLocator;
    }

    /**
     * {@inheritDoc}
     */
    public void close() {
        this.delegate.close();
    }

    /**
     * {@inheritDoc}
     */
    public void connect() throws IOException, HttpException {
        this.delegate.connect();
    }

    /**
     * {@inheritDoc}
     */
    public boolean isConnected() {
        return this.delegate.isConnected();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public synchronized Tuple<InputStream, MultiMap<String, String>> doMakeRequestWithBody(String path, HttpMethod method,
            MultiMap<String, String> headerParameters, MultiMap<String, String> queryParameters, InputStream requestBody)
            throws HttpException, IOException {
        if (this.status == STARTED) {
            this.performHandshake(path);
        }
        try {
            InputStream ciphered = this.encode(requestBody);
            Tuple<InputStream, MultiMap<String, String>> request =
                    this.delegate.makeRequestWithBody(path, method, headerParameters, queryParameters, ciphered);
            return UnmodifiableTuple.newTuple(this.decodeInput(request.head()), request.tail());
        } catch (InvalidKeyException e) {
            resetCommunicationProtocol();
            throw RetryRequestException.SHARED_INSTANCE;
        } catch (HttpClientErrorException hcee) {
            if (hcee.getHttpResponse() == SecureCommunicationProtocol.INITIALIZATION_NEEDED) {
                resetCommunicationProtocol();
                throw RetryRequestException.SHARED_INSTANCE;
            } else {
                throw hcee;
            }
        }
    }

    /**
     * Reseta para o estado inicial
     */
    private void resetCommunicationProtocol() {
        this.decodeKey = originalDecodeKey;
        this.encodeKey = null;
        this.status = STARTED;
    }

    /**
     * Passa o conteudo do {@link InputStream} passado pelo encriptador
     * 
     * @param requestBody {@link InputStream}
     * @return {@link InputStream}
     * @throws IOException e
     */
    private InputStream encode(InputStream requestBody) throws IOException {
        if (requestBody == null) {
            return null;
        }
        ReversibleOutputStream reversible = new ReversibleOutputStream();
        OutputStream wrapOutputStream;
        try {
            wrapOutputStream = new CipherOutputStream(reversible, GeradorChaveRSA.copyCipher(this.cipher), this.encodeKey, this.chunkSize);
            IOUtils.copyFullyClose(requestBody, wrapOutputStream);
            return reversible.reverse();
        } catch (InvalidKeyException e) {
            throw new IOException(e.getMessage());
        }
    }

    /**
     * @param path String
     * @throws IOException e
     * @throws HttpException e
     */
    private void performHandshake(String path) throws IOException, HttpException {
        if (status == STARTED) { // repeated calls are ignored
            InputStream inputStream = this.startHandshake(path);
            ObjectInputStream oin = new ObjectInputStream(inputStream);
            try {
                InputStream input = this.respondAssertion(path, oin);
                if (serverSupportsSymetricKeys) {
                    byte[] encodedKey = IOUtils.readFully(input);
                    try {
                        DESKeySpec spec = new DESKeySpec(encodedKey);
                        SecretKeyFactory fac = SecretKeyFactory.getInstance("DES");
                        this.encodeKey = fac.generateSecret(spec);
                        this.decodeKey = this.encodeKey;
                        this.cipher = Cipher.getInstance(this.cipherName);
                    } catch (InvalidKeyException e) {
                        this.status = STARTED;
                        throw new RuntimeException(e);
                    } catch (NoSuchAlgorithmException e) {
                        throw new RuntimeException(e);
                    } catch (NoSuchPaddingException e) {
                        throw new RuntimeException(e);
                    }
                } else {
                    try {
                        // it should now have the real encode key
                        this.encodeKey = GeradorChaveRSA.leChavePublica(input);
                    } finally {
                        input.close();
                    }
                    this.negotiateCommunicationKeys(path);
                }
            } catch (ClassNotFoundException e) {
                this.encodeKey = null;
                throw new IOException(e.getMessage());
            } catch (InvalidKeySpecException e) {
                this.encodeKey = null;
                throw new IOException(e.getMessage());
            } finally {
                inputStream.close();
            }
        }
    }

    /**
     * Decora o InputStream passado, decodificando dados criptografados
     * 
     * @param input o {@link InputStream}
     * @return {@link InputStream}
     * @throws IOException e
     * @throws InvalidKeyException e
     */
    protected InputStream decodeInput(InputStream input) throws IOException, InvalidKeyException {
        return new CipherInputStream(input, GeradorChaveRSA.copyCipher(this.cipher), this.decodeKey);
    }

    /**
     * Cria um novo par de chaves que deve ser usado a partir desse momento
     * 
     * @param path String
     * @throws IOException e
     * @throws HttpException e
     */
    private void negotiateCommunicationKeys(String path) throws IOException, HttpException {
        KeyPair keyPair = new DynamicKeyPairHolder().get();
        this.decodeKey = keyPair.getPrivate();
        this.doMakeRequestWithBody(path, HttpMethod.POST, new ListMultiMap<String, String>(), new ListMultiMap<String, String>(),
                new ByteArrayInputStream(keyPair.getPublic().getEncoded()));
    }

    /**
     * Le a assertion do ObjectInputStream passado, calcula a resposta e devolve em um request novo
     * 
     * @param path o path
     * @param oin o ObjectInputStream
     * @return InputStream de onde ler a chave nova
     * @throws IOException e
     * @throws ClassNotFoundException nao deveria ser lancado nunca
     * @throws HttpException e
     */
    private InputStream respondAssertion(String path, ObjectInputStream oin) throws IOException, ClassNotFoundException, HttpException {
        String question = (String) oin.readObject();
        this.encodeKey = this.keyLocator.getPublicKey(serverCertificateName);
        String answer = MD5SumTool.md5Sum(question);

        try {
            this.cipherName = (String) oin.readObject();
            serverSupportsSymetricKeys = true;
            // this.cipher = Cipher.getInstance(alg);
        } catch (EOFException e) {
            // falling back to assymentric keys
            serverSupportsSymetricKeys = false;
        }
        Tuple<InputStream, MultiMap<String, String>> tuple =
                this.doMakeRequestWithBody(path, HttpMethod.POST, new ListMultiMap<String, String>(), new ListMultiMap<String, String>(),
                        new ByteArrayInputStream(answer.getBytes(EncodingUtils.UTF_CHARSET)));
        return tuple.head();
    }

    /**
     * Inicia o handshake, fazendo um request mandando o keyPath e com um parametro extra
     * 
     * @param path o path
     * @return {@link InputStream}
     * @throws IOException e
     * @throws HttpException e
     */
    private InputStream startHandshake(String path) throws IOException, HttpException {
        this.status = NEGOTIATING;
        HashMap<String, String> params = new HashMap<String, String>();
        this.cipher = this.handshakeCipher;
        params.put("keyPath", this.keyPath);
        params.put("initialize", "true");
        if (this.useSymetricKey) {
            params.put("encryption", "symetric");
        }
        InputStream inputStream = this.makeGet(path, params);
        return inputStream;
    }

    /**
     * {@inheritDoc}
     */
    public int getStatus() {
        return status;
    }
}
